C#网络编程

使用 ASP.NET Core 搭建 WebSocket 服务器

作者:陈广 日期:2018-12-19


上一篇文章我们讲解了 WebSocket 在浏览器端的实现,当然,使用的完全是 JavaScript,接下来就应该讲如何在服务器端实现 WebSocket 了。所有后台语言都可以搭建 WebSocket 服务,我这只有 ASP.NET Core。所以下面我将演示如何使用 ASP.NET Core 搭建 WebSocket 服务器。

创建项目

本想在上一篇文章的基础上直接创建项目的,但最后发现上篇文章的项目名称为 WebSocket,跟本文需要使用的类重名。名字没起好,干脆还是重建吧。

新建一个名为 WSDemo 的文件夹,在右键菜单上选择【Open with Code】打开此文件夹。按下【Ctrl + ~】快捷键打开终端,输入如下命令:

dotnet new empty

创建项目完成后,首先关掉 HTTPS,打开 Properties 文件夹下的 launchSettings.json 文件,将sslPort项的值更改为0以关闭 HTTPS。将applicationUrl项的值更改如下:

"applicationUrl": "http://localhost:5000",

加入 WebSocket 中间件

接下来将 Startup.cs 代码更改如下:

using System;
using System.Collections.Generic;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;

namespace WSDemo
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            app.UseWebSockets(); //加入 WebSocket 中间件
            //加入处理消息中间件
            app.Use(async (context, next) =>
            {   //此处指定了请求 URL 的 Path 为 /ws,即完整URL为:ws://localhost:5000/ws
                if (context.Request.Path == "/ws") 
                {
                    if (context.WebSockets.IsWebSocketRequest) //如果是 WebSocket 请求
                    {   //等待客户端的连接
                        WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync();
                        await Echo(context, webSocket); //处理连接
                    }
                    else
                    {   //如果 ws://localhost:5000/ws 发送过来的并不是 WebSocket 请求,则返回400错误
                        context.Response.StatusCode = 400;
                    }
                }
                else
                {//如果请求 URL 的 Path 部分不是 /ws,则发给下一个中间件处理
                    await next(); 
                }

            });
            //使用静态文件,首页显示就靠它了
            app.UseFileServer();
        }

        private async Task Echo(HttpContext context, WebSocket webSocket)
        {
            var buffer = new byte[1024 * 4]; //接收缓冲
            //等待接收
            WebSocketReceiveResult result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
            while (!result.CloseStatus.HasValue)
            {   //收到的内容原样发回
                await webSocket.SendAsync(new ArraySegment<byte>(buffer, 0, result.Count), result.MessageType, result.EndOfMessage, CancellationToken.None);
                //再次等待接收
                result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
            }
            //关闭连接
            await webSocket.CloseAsync(result.CloseStatus.Value, result.CloseStatusDescription, CancellationToken.None);
        }
    }
}

代码解析

这一段代码参考了微软给出的示例代码。DotNet 2.1 版本已经内置了 WebSocket 程序包,如果您使用的 DotNet 版本没有 WebSocket,请运行以下命令安装:

dotnet add package Microsoft.AspNetCore.WebSockets --version 2.1.1

配置中间件

有关中间件,可以参考自由男的《Pro ASP.NET Core MVC 2(第7版》这本书的《配置应用程序》这一章,里面有详细讲解。这里我大概讲一下。如果在写程序时,有一个东西需要分很多的步骤处理,我们可以把各个步骤包装在方法内,然后在一个方法里进行统一调用。这好比早期的生产线,各个部件在生产线的不同节点进行装配,走完整个生产线后,就装配成为成品。这类生产线的缺陷是每一步只做固定的事情,从各节点的先后顺序都是事先设计好的,最终所有生产出来的东西都是一样的。

中间件的出现,使得每个节点变为模块化,你可以选择需要的模块来装配,各模块之间是随意组合的。先使用哪个模块,后使用哪个模块都可以根据需要而定。甚至于整个流程只走到一半就已经完成而退出生产线成为成品。产品按需订制,最终一条生产线生产出来的产品可以是各种各样的。这不就是活脱脱的工业 4.0、中国制造 2025 嘛。想不到工业领域还没有完全实现的东西在软件领域早已使用了。

使用 WebSocket,需要引入using System.Net.WebSockets命名空间。然后通过在 Startup 类的Configure方法中加入如下代码以加入 WebSocket 中间件。

app.UseWebSockets();

WebSocket 中的一些选项可供配置:

  • KeepAliveInterval : 向客户端发送 “ping” 帧的频率,以确保代理保持连接处于打开状态。 默认值为 2 分钟。
  • ReceiveBufferSize : 用于接收数据的缓冲区的大小。 高级用户可能需要对其进行更改,以便根据数据大小进行调整,优化性能。 默认值为 4 KB。
  • AllowedOrigins : 用于 WebSocket 请求的允许的Origin header 值列表。 默认情况下,允许使用所有源。

如果需要更改配置,可使用以下代码:

var webSocketOptions = new WebSocketOptions()
{
    KeepAliveInterval = TimeSpan.FromSeconds(120),
    ReceiveBufferSize = 4 * 1024
};
app.UseWebSockets(webSocketOptions);

如果在 https://server.com 上托管服务器并在 https://client.com 上托管客户端,请将 https://client.com 添加到 AllowedOrigins 列表以验证 WebSocket。

app.UseWebSockets(new WebSocketOptions()
{
    AllowedOrigins.Add("https://client.com");
    AllowedOrigins.Add("https://www.client.com");
});

发送和接收消息

AcceptWebSocketAsync方法将 HTTP 连接升级到 WebSocket 连接。对应的是上一篇文章中的 HTTP 请求中的 header:

Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13

连接升级后,AcceptWebSocketAsync方法将返回一个WebSocket对象,可通过WebSocket对象发送和接收消息。这里将WebSocket对象传递给了Echo方法,此方法接收消息并立即返回相同的消息。循环发送和接收消息,直到客户端关闭连接。其中,发送消息使用了SendAsync方法,接收消息使用ReceiveAsync。第一次在微软示例代码里到使用asyncawait处理 Socket,不容易,改天拿这套东西去改写之前文章中的 Socket 编程,看看适不适用。

加入主页

既然是 Web 程序,当然需要先从服务器下载主页,然后通过主页向服务器发送请求并聊天。我们还是使用上一篇文章已经做好的主页。

在 wwwroot 文件夹下新建一个名为 Index.html 的文件,使用如下代码:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>IOT小分队</title>
</head>

<body>
    <div id="echo">
        <div id="echo-config" style="float: left;">
            <strong>Location:</strong><br>
            <input class="draw-border" id="wsUri" value="ws://localhost:5000/ws" size="35">
            <br>
            <p id="stateLabel" style="color:blue">Ready to connect...</p>
            <button class="echo-button" id="connect">Connect</button>
            <button class="echo-button" id="disconnect">Disconnect</button>
            <br>
            <br>
            <strong>Message:</strong><br>
            <input class="draw-border" id="sendMessage" size="35" value="Rock it with HTML5 WebSocket">
            <br>
            <button class="echo-button" id="send" class="wsButton">Send</button>
        </div>
        <div id="echo-log" style="float: left; margin-left: 20px; padding-left: 20px; width: 360px; border-left: solid 1px #cccccc;">
            <strong>Log:</strong><br>
            <textarea id="consoleLog" style="width: 350px; height: 200px; border: solid 1px #cccccc"></textarea>
            <button class="echo-button" id="clearLogBtn" style="position: relative; top: 3px;">Clear log</button>
        </div>
    </div>
</body>
<script src='echo.js'></script>
</html>

这里唯一做的改变就是更改了存放 URI 的文本框的内容:ws://localhost:5000/ws。由于在Startup类中使用app.UseFileServer();加入文件服务器中间件,使得我们可以直接使用静态文件作为首页。

加入 JavaScript

接下来在 wwwroot 文件夹中新建一个 echo.js 文件,输入如下代码:

var wsUri = document.getElementById('wsUri');
var stateLabel = document.getElementById("stateLabel");
var connectBtn = document.getElementById('connect');
var disconnectBtn = document.getElementById('disconnect');
var sendMessage = document.getElementById('sendMessage');
var sendBtn = document.getElementById('send');
var consoleLog = document.getElementById('consoleLog');
var clearLogBtn = document.getElementById('clearLogBtn');
var socket;
//更新状态
function updateState() {
    function disable() {
        sendMessage.disabled = true;
        sendBtn.disabled = true;
        disconnectBtn.disabled = true;
    }
    function enable() {
        sendMessage.disabled = false;
        sendBtn.disabled = false;
        disconnectBtn.disabled = false;
    }
    wsUri.disabled = true;
    connectBtn.disabled = true;
    if (!socket) {
        disable();
    } else {
        switch (socket.readyState) {
            case WebSocket.CLOSED:
                stateLabel.innerHTML = "Closed";
                disable();
                wsUri.disabled = false;
                connectBtn.disabled = false;
                break;
            case WebSocket.CLOSING:
                stateLabel.innerHTML = "Closing...";
                disable();
                break;
            case WebSocket.CONNECTING:
                stateLabel.innerHTML = "Connecting...";
                disable();
                break;
            case WebSocket.OPEN:
                stateLabel.innerHTML = "Open";
                enable();
                break;
            default:
                stateLabel.innerHTML = "Unknown WebSocket State: " + socket.readyState;
                disable();
                break;
        }
    }
}
//断开连接
disconnectBtn.onclick = function () {
    if (!socket || socket.readyState !== WebSocket.OPEN) {
        alert("socket not connected");
    }
    socket.close(1000, "Closing from client");
};
//发送消息
sendBtn.onclick = function () {
    if (!socket || socket.readyState !== WebSocket.OPEN) {
        alert("socket not connected");
    }
    var data = sendMessage.value;
    socket.send(data);
    logText("SEND:" + data);
};
//开始连接
connectBtn.onclick = function () {
    logText("Connecting");
    socket = new WebSocket(wsUri.value);
    socket.onopen = function (event) {
        updateState();
        logText("Connection opened");
    };
    socket.onclose = function (event) {
        updateState();
        logText('Connection closed. Code: ' + event.code + '. Reason: ' + event.reason);
    };
    socket.onerror = updateState;
    socket.onmessage = function (event) {
        logText("RECEIVE:" + event.data);
    };
};
//清除消息框内容
clearLogBtn.onclick = function () {
    consoleLog.value = "";
}

//写入消息框
function logText(text) {
    if (consoleLog.value == "") {
        consoleLog.value += text;
    } else {
        consoleLog.value += "\r\n" + text;
    }
    consoleLog.scrollTop = consoleLog.scrollHeight
}

这段代码与使用的完全是上篇文章的 JavaScript 代码。这里不再赘述。

运行程序

运行程序,结果如下图所示:

图 1: 运行程序

效果和上篇文章的一样,只是上篇文章我们访问的是 WebSocket 官网搭建的服务器,而这次使用的是我们自己搭建的服务器。

将处理程序封装为中间件

上面的程序直接在Startup类的Configure方法内,使用app.Use编写了 WebSocket 请求的处理的逻辑。这当然不是一个好主意,实际开发中,Startup类用于配置,不应当处理程序逻辑。那么接下来,我们就将处理逻辑封闭在中间件中,然后在Startup中配置此中间件。

编写中间件

在 WsDemo 项目下新建一个名为 Infrastructure 的文件夹,并在其中新建一个名为 WsHandleMiddleware.cs 的文件,输入如下代码:

using System;
using System.Text;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using System.Net.WebSockets;

namespace WSDemo.Infrastructure
{
    public class WsHandleMiddleware
    {
        private RequestDelegate nextDelegate;
        public WsHandleMiddleware(RequestDelegate next) => nextDelegate = next;
        public async Task Invoke(HttpContext context)
        {
            if (context.Request.Path == "/ws")
            {
                if (context.WebSockets.IsWebSocketRequest)
                {
                    WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync();
                    await Echo(context, webSocket);
                }
                else
                {
                    context.Response.StatusCode = 400;
                }
            }
            else
            {
                await nextDelegate.Invoke(context);
            }
        }
        private async Task Echo(HttpContext context, WebSocket webSocket)
        {
            var buffer = new byte[1024 * 4]; //接收缓冲
            //等待接收
            WebSocketReceiveResult result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
            while (!result.CloseStatus.HasValue)
            {   //收到的内容原样发回
                await webSocket.SendAsync(new ArraySegment<byte>(buffer, 0, result.Count), result.MessageType, result.EndOfMessage, CancellationToken.None);
                //再次等待接收
                result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
            }
            //关闭连接
            await webSocket.CloseAsync(result.CloseStatus.Value, result.CloseStatusDescription, CancellationToken.None);
        }
    }
}

这一次我们将中间件代码移到一个单独的文件中。内容基本没变。

在 Startup 中调用中间件

打开 Startup.cs 文件,将代码修改如下:

using System;
using System.Collections.Generic;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using WSDemo.Infrastructure;

namespace WSDemo
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            app.UseWebSockets(); //加入 WebSocket 中间件
            //加入处理消息中间件
            app.UseMiddleware<WsHandleMiddleware>();
            //使用静态文件,首页显示就靠它了
            app.UseFileServer();
        }
    }
}

这一次,Startup类的代码清爽了很多,它只做它应该做的事。

运行程序,效果和之前的一模一样。

;

© 2018 - IOT小分队文章发布系统 v0.3